Skip to content

Pg graphql authenticated table and other updates#160

Merged
samrose merged 2 commits into
mainfrom
pg-graphql-authenticated-table
Apr 27, 2026
Merged

Pg graphql authenticated table and other updates#160
samrose merged 2 commits into
mainfrom
pg-graphql-authenticated-table

Conversation

@samrose
Copy link
Copy Markdown
Contributor

@samrose samrose commented Apr 26, 2026

Summary

Closes correctness gaps in splinter's pg_graphql introspection coverage and adds a paired pair of lints for the SECURITY DEFINER function exposure path, which is often the largest data-leak surface in practice. After this PR, the four lints below cover the four direct exposure paths to public-API roles: anon-vs-authenticated ×
tables-vs-functions.

The four lints are:

  • 0026_pg_graphql_anon_table_exposed (WARN) — a relation is visible in pg_graphql introspection because anon can read at least one column.
  • 0027_pg_graphql_authenticated_table_exposed (WARN, new) — a relation is visible in pg_graphql introspection because authenticated can read at least one column.
  • 0028_anon_security_definer_function_executable (WARN, new) — a SECURITY DEFINER function in a user schema is executable by anon.
  • 0029_authenticated_security_definer_function_executable (WARN, new) — a SECURITY DEFINER function in a user schema is executable by authenticated.

All four are EXTERNAL, SECURITY. The name column on 0026 is unchanged so existing Studio integrations (supabase/supabase #45253) keep matching.

Conditions covered

0026 — pg_graphql introspection (anon)

Fires when all of the following are true: the pg_graphql extension is installed; the relation lives in a non-system schema; the relation's relkind is one of 'r' (table), 'v' (view), 'm' (materialized view), or 'f' (foreign table); and the anon role has SELECT on at least one column of the relation. The privilege check is
column-level (has_column_privilege over pg_attribute), so both table-level grants and column-only grants such as GRANT SELECT (col) ON t TO anon fire the lint. Metadata exposed: schema, name, type.

0027 — pg_graphql introspection (authenticated)

Same conditions as 0026 against the authenticated role. This is the second half of the introspection check: in default Supabase projects anon and authenticated start with identical default-privilege grants, so revoking from one alone usually leaves the introspection response unchanged for the other. An operator following the
original 0026 doc verbatim — "revoke from anon, grant to authenticated" — would clear 0026 and still serve a byte-for-byte unchanged introspection response to every signed-up user. 0027 catches that residual exposure. Metadata exposed: schema, name, type.

0028 — SECURITY DEFINER function executable by anon

Fires when all of the following are true: the function lives in a non-system schema; the function has prosecdef = true; and the anon role has EXECUTE on the function (direct grant, role membership, or via PUBLIC — which is the Postgres default for new functions). Does not gate on pg_graphql being installed: PostgREST exposes the
function at /rest/v1/rpc/ independently. Metadata exposed: schema, name, arguments, language, security_definer. The arguments value comes from pg_get_function_identity_arguments so overloaded functions produce one finding per signature with a distinct cache_key.

SECURITY INVOKER functions are deliberately excluded — they execute as the caller and RLS still applies to any tables they touch. The risk pattern these lints target is RLS bypass via privilege escalation in SECURITY DEFINER. The underlying-table risk for SECURITY INVOKER functions is owned by lints 0008 and 0013.

0029 — SECURITY DEFINER function executable by authenticated

Same conditions as 0028 against the authenticated role. Same metadata shape.

Changes to existing 0026 behaviour

0026 was originally added in PR #158. Three correctness gaps are fixed in this PR.

The first is role coverage. The original lint only checked anon. pg_graphql introspection runs under whichever role the caller's JWT claims, so anon and authenticated see independent introspection responses. The original remediation in the docs ("revoke from anon, grant to authenticated") could be followed verbatim and leave the
introspection response served to every signed-up user byte-for-byte unchanged. The fix is to keep 0026 focused on anon, add a parallel 0027 for authenticated, and rewrite the docs to make the pairing explicit.

The second is the relkind filter. The original filter was ('r','p','v','m'). pg_graphql's actual filter at load_sql_context.sql:395-400 is ('r','v','m','f'). The fix is to drop 'p' (partitioned table roots are not exposed by pg_graphql; their leaf partitions remain covered as 'r') and add 'f' (foreign tables, which pg_graphql
does expose).

The third is privilege-predicate granularity. The original used has_table_privilege(role, oid, 'SELECT'). pg_graphql actually uses has_column_privilege per column at load_sql_context.sql:327 (populating is_selectable) and sql_types.rs:594 (is_any_column_selectable returning columns.iter().any(|x| x.permissions.is_selectable)). A
relation where the role has only a column-level grant — GRANT SELECT (some_col) ON t TO anon — would be exposed by pg_graphql but missed by the lint. Insert/Update/Delete entrypoints share the same prerequisite (graphql.rs:194-209), so this gap also masked mutation exposures. The fix is to replace has_table_privilege with an
EXISTS over pg_attribute calling has_column_privilege, filtered to live columns (attnum > 0 AND NOT attisdropped). This is a strict superset of the previous behaviour: anything that fired before still fires, and column-only grants now fire too.

Why these are paired in one PR

The four lints are mutually reinforcing. An operator who acts on 0026 alone could clear it and still be wide open via 0027, 0028, or 0029. Shipping them together so the docs can cross-link, the remediation guidance can land coherently, and Studio can render the related findings as a coherent group avoids review thrash and
prevents a partial mitigation from looking complete. The pg_graphql introspection lints (0026/0027) and the SECURITY DEFINER function lints (0028/0029) cover related-but-distinct exposure paths; the docs cross-reference each other so an operator reading any one finding is pointed at the others.

Documentation

Each doc has the same up-front structure: level, summary, ramification, a "see also" call-out cross-linking the paired lint, and an "If you are not using pg_graphql, disable it" call-out near the top. For 0026/0027 disabling pg_graphql fully closes the introspection surface. For 0028/0029 it only closes the /graphql/v1 half —
the function is still callable via PostgREST /rest/v1/rpc, so the lint keeps firing and the rest of the doc still applies. The doc says so explicitly in each case.

The remediation sections cover the same three options across all four lints: revoke (always including PUBLIC because Postgres default grants live there), narrower per-relation revoke for the targeted finding, and full endpoint shutdown for projects that do not use /graphql/v1 at all. Each doc also has verification snippets
showing the exact curl calls to confirm the fix from both /graphql/v1 and /rest/v1/rpc, plus a quick-reference SQL table and a false-positives section.

Test coverage

0026's test covers: baseline (no extension, 0 rows); pg_graphql-not-installed negative; positive on table + view + materialized view together (all three relkinds in the filter, asserting 3 rows alphabetically ordered); revoke-clears resolution; a foreign-table positive case proving relkind='f' fires; a partitioned-table mixed
case proving the root relkind='p' does NOT fire while the leaf 'r' does; a negative case where anon is revoked but authenticated keeps SELECT (the exact "operator followed the original 0026 doc" state, asserts 0 rows); and a column-only-grant positive case proving GRANT SELECT (id) ON t TO anon fires the lint without any
table-level or PUBLIC grant.

0027's test mirrors the same shape against authenticated.

0028's test covers: baseline; SECURITY INVOKER negative (proving the prosecdef = true filter works); SECURITY DEFINER positive with the default PUBLIC EXECUTE grant (the most common accidental exposure, since Postgres' default is EXECUTE to PUBLIC); revoke-clears resolution; an overload positive case proving two functions
sharing a name with different argument lists produce two findings with distinct cache_keys driven by pg_get_function_identity_arguments; a DEFINER-but-EXECUTE-revoked negative; and a DEFINER-in-system-schema negative confirming the exclusion list.

0029's test mirrors the same shape against authenticated.

queries_are_unionable is extended with the two new union members and confirms column-shape parity across the suite.

All 28 tests pass under bin/installcheck against supabase/postgres:15.1.1.13.

Other changes

bin/installcheck adds -f lints/0027*.sql -f lints/0028*.sql -f lints/0029*.sql so the new lints load alongside the rest. splinter.sql is regenerated via bin/compile.py (1547 → 1810 lines) and includes all four lints in the bundled UNION ALL.

The shared system-schema exclusion list — copied across most lint files — listed 'pgtle' twice. This was swept across 18 lint files. Purely cosmetic, no behavioural change, but worth doing once to prevent future copy-paste.

Verification

bin/compile.py
SUPABASE_VERSION=15.1.1.13 docker-compose -f dockerfiles/docker-compose.yml run --build --rm test

All 28 tests passed.

Files

New: lints/0027_pg_graphql_authenticated_table_exposed.sql, lints/0028_anon_security_definer_function_executable.sql, lints/0029_authenticated_security_definer_function_executable.sql, the matching three docs under docs/, the matching three test SQL files under test/sql/, and the matching three expected-output files under
test/expected/.

Modified: lints/0026_pg_graphql_anon_table_exposed.sql for relkind / column-level privilege / comments / cross-reference; docs/0026_pg_graphql_anon_table_exposed.md for the disable-extension callout / relkind note / cross-reference / remediation alignment; test/sql/0026_pg_graphql_anon_table_exposed.sql and its expected output;
test/sql/queries_are_unionable.sql and its expected output; bin/installcheck; splinter.sql. Plus 17 other lints/*.sql files where the duplicate 'pgtle' was swept from the system-schema exclusion list.

@samrose samrose force-pushed the pg-graphql-authenticated-table branch from 2132601 to 0f5042e Compare April 26, 2026 22:56
@deepthi deepthi requested a review from dnywh April 26, 2026 23:09
@samrose samrose changed the title Pg graphql authenticated table Pg graphql authenticated table and other updates Apr 26, 2026
@deepthi deepthi requested a review from soedirgo April 26, 2026 23:13
Copy link
Copy Markdown
Contributor

@dnywh dnywh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed from a design perspective only. Naming, pairing, and remediation flow makes sense. Will test on Studio separately.

Signed-off-by: Bobbie Soedirgo <bobbie@soedirgo.dev>
@samrose samrose merged commit 18b4bb9 into main Apr 27, 2026
2 checks passed
samrose added a commit that referenced this pull request Apr 27, 2026
#160 added a pgrst.db_schemas filter to the function lints to match
  PostgREST's API exposure scope, but the test SQL didn't set the
  guc, so the positive cases stopped firing.

  Mirrors the pattern from test/sql/0023_sensitive_columns_exposed.sql.
joshenlim pushed a commit to supabase/supabase that referenced this pull request Apr 27, 2026
…R functions (#45260)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature — wires up three new advisor lints landed in splinter, and
updates the self-hosted SQL bundle for the existing
`pg_graphql_anon_table_exposed` lint to track splinter's correctness
fixes. Companion to `supabase/splinter` #160 (already merged) and #162
(test fix in flight).

## What is the current behavior?

Splinter's `main` now exposes four lints in the pg_graphql / SECURITY
DEFINER family:

- `pg_graphql_anon_table_exposed` (0026, existing) — wired into Studio
in #45253; SQL in `packages/pg-meta` is the original version that uses
`has_table_privilege` and the relkind set `('r','p','v','m')`.
- `pg_graphql_authenticated_table_exposed` (0027, new) — paired check
against the `authenticated` role. Studio renders any new finding without
a `lintInfoMap` entry as a row with no icon, no title mapping, and no
"Fix" CTA. Self-hosted users do not see the lint at all because
`packages/pg-meta` does not include it.
- `anon_security_definer_function_executable` (0028, new) — `SECURITY
DEFINER` function executable by `anon`. Same Studio + self-hosted gaps
as 0027.
- `authenticated_security_definer_function_executable` (0029, new) —
same against `authenticated`.

Splinter has also updated 0026 itself (PR #160) in two ways that need to
flow into the self-hosted SQL bundle:
1. **`relkind` filter:** `('r','p','v','m')` → `('r','v','m','f')`.
Drops partitioned table roots (pg_graphql does not expose them; their
leaf partitions are still covered as `'r'`) and adds foreign tables,
which pg_graphql does expose.
2. **Privilege predicate:** `has_table_privilege(role, oid, 'SELECT')` →
`EXISTS` over `pg_attribute` calling `has_column_privilege`. Catches
column-level grants such as `GRANT SELECT (col) ON t TO anon`, which
pg_graphql's introspection exposes but `has_table_privilege` missed.

Cloud projects auto-fetch `splinter.sql` via the platform mgmt-api's
`getLintSql` (1-hour cache TTL), so they pick up #160's lint and SQL
changes independently of this PR. This PR is about the Studio display
mapping and the self-hosted SQL bundle.

## What is the new behavior?

Two minimal additions, mirroring the integration shape of #45253.

### `apps/studio/components/interfaces/Linter/Linter.utils.tsx`

Three new entries appended to `lintInfoMap`:

- `pg_graphql_authenticated_table_exposed` — `Eye` icon (paired with the
existing `pg_graphql_anon_table_exposed` entry); link points to the
Table Editor scoped to `metadata.schema` + `metadata.name`; `linkText:
'View object'`; `category: 'security'`.
- `anon_security_definer_function_executable` — `Unlock` icon (signals
"this thing is callable when it shouldn't be"); link points to the
Database Functions browser scoped to `metadata.schema` +
`metadata.name`; `linkText: 'View function'`; `category: 'security'`.
- `authenticated_security_definer_function_executable` — same as 0028
against `authenticated`.

Each entry's `docsLink` points at the splinter-hosted lint doc.

### `packages/pg-meta/src/sql/studio/advisor/lints.ts`

The existing `pg_graphql_anon_table_exposed` SQL block is updated in
place to match the new splinter version: new `relkind` set, `case`
statement for `'f'`, and the `EXISTS` over `pg_attribute` privilege
check. Three new `union all` blocks are appended for 0027/0028/0029. The
function lints (0028/0029) include the `pgrst.db_schemas` filter
(mirroring lint `0023_sensitive_columns_exposed`) so findings are scoped
to schemas PostgREST actually exposes; the self-hosted query wrapper
already sets the GUC when `exposedSchemas` is passed
(`enrichLintsQuery`).

## Coverage of the four exposure paths

| Role | Tables/views/MVs/foreign tables | SECURITY DEFINER functions |
|------|---------|----------|
| `anon` | 0026 (existing, updated) | 0028 (new) |
| `authenticated` | 0027 (new) | 0029 (new) |

The 0026/0027 pair covers `pg_graphql` introspection visibility; the
0028/0029 pair covers RLS bypass via privileged function execution
through `/rest/v1/rpc` (and `/graphql/v1` for compatible return types).
Each lint's doc cross-references its sibling so an operator hitting one
is steered toward the others.

## Verification

- `cd packages/pg-meta && npx tsc --noEmit` — clean.
- `cd apps/studio && npx tsc --noEmit` — clean for the changed file.
(Other unrelated TS errors exist in the working tree but are
pre-existing and not introduced by this PR.)
- `cd apps/studio && npx eslint
components/interfaces/Linter/Linter.utils.tsx` — clean.

## Files

- `apps/studio/components/interfaces/Linter/Linter.utils.tsx` — adds
three `lintInfoMap` entries (0027, 0028, 0029).
- `packages/pg-meta/src/sql/studio/advisor/lints.ts` — updates the 0026
SQL block to match splinter's correctness fixes, appends 0027/0028/0029
SQL blocks.

## Related

- supabase/splinter#160 — adds 0027/0028/0029 and rewrites 0026
(merged).
- supabase/splinter#162 — fixes test setup for 0028/0029 (in flight;
does not affect the SQL shipped here).
- #45253 — original 0026 Studio integration.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added security linting to detect authenticated-table exposure and
executable SECURITY DEFINER functions.
  * Added signed-in visibility checks alongside anonymous checks.

* **Bug Fixes / Improvements**
* Improved relation type handling for accurate table/foreign/partition
classification.
  * Switched to column-level privilege analysis for visibility.
* Improved entity naming shown in lints (includes function argument
display).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants